Skip to content

(agents): a view of the sessions the claude daemon runs in the background - #374

Merged
devsuitup merged 50 commits into
devsuitup:mainfrom
paulo-jay:worktree-background-agents-view-impl
Oct 7, 2026
Merged

devsuitup merged 50 commits into
devsuitup:mainfrom
paulo-jay:worktree-background-agents-view-impl

Conversation

@paulo-jay

@paulo-jay paulo-jay commented Sep 30, 2026 •

Copy link
Copy Markdown

A graphical replacement for the claude agents TUI: a dedicated Agents view that lists the sessions the Claude daemon runs in the background, shows what each one does, and attaches to, stops, respawns, deletes or dispatches them.

What it does

  • List. The daemon's --bg sessions plus the interactive sessions running outside this Switchboard, read from ~/.claude/jobs/*/state.json and the CLI's session descriptors, reconciled by claude agents --json --all. Open it with the people icon in the sidebar filter row or Ctrl/Cmd+Shift+A (rebindable).
  • Grouping. A Group menu: by State (default), by Project, or none. State groups carry an emoji + label (⚙️ Working, ✋ Blocked, ✅ Done, ⏹️ Stopped, ❌ Failed, 🖥️ External, ❓ Unknown), and the same emoji starts each row's state column. Project mode merges all the worktrees of one git project under the main working tree (.claude/worktrees/<name> or any git worktree add); a Worktrees option (on by default) adds a second level per worktree for projects that have several.
  • Folding. Every group header (state, project, worktree) folds on click / Enter / Space; the folded set is remembered.
  • Attach / detach. Attach (the button, or a double click on a live row) opens claude attach <id> in a terminal tab keyed by the session's real id; closing it detaches (Ctrl+Z, 2 s grace, then kill), it never stops the job. A click in the sidebar on a session the daemon runs attaches instead of asking to resume; such sessions carry a bg badge.
  • Stop, respawn, delete, dispatch. Through the CLI via the login shell with a quoted argv. A live job (working or blocked) can only be stopped or attached to; it is never resumed or forked. New agent opens a dispatch dialog (prompt, name, project, agent, permission options).
  • Failure is silence. ~/.claude/jobs/ and the kind: "bg" descriptor are undocumented interfaces: if the daemon does not answer, the view falls back to the files and shows a banner. Canary tests pin the observed shapes (CLI 2.1.285).

Design notes: .ai/contexts/bg-agents.md; user doc: docs/background-agents.md
Context: .ai/contexts/bg-agents.md (includes "Known limits"); user doc: docs/background-agents.md

Found while building

  • The daemon reports job states beyond what was first assumed: blocked (a live job waiting on input, treated as live everywhere) and failed (finished).
  • claude --bg prints backgrounded · <id> · <name> with the id in ANSI colour even when piped; a never-trusted cwd makes it refuse ("Workspace not trusted").
  • The frameless window draws the system buttons over the top-right corner: the Agents header joins the window-frameless inset lists (right and left) so New agent stays clear of them, and its labels are no-drag.
  • A row's grid needs ~670 px, which made #main run past the window edge in narrow windows (it has no min-width); #main { min-width: 0 }, and the header wraps its controls.
  • The New background agent dialog reuses .new-session-dialog, which has no height limit; it gets its own class (max-height + overflow-y: auto) and scrolls on short screens. The shared class is left alone on purpose.

Known limits (details in the context doc)

dispatch errors can carry login-shell noise ahead of the CLI message; a noise line that is itself a valid JSON array can win the list parse; MAX_JOBS (200) truncates by id, not recency; interactive-descriptor liveness is pid-only; resolved project/worktree roots are cached until the window closes; a submodule or bare repo is its own project; runVerb's live-guard passes rm/respawn when the roster lacks the job (unreachable from the UI).

https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo

pjay added 30 commits September 30, 2026 16:40
A graphical replacement for the claude agents TUI: a dedicated view fed by
the daemon's job files and the session descriptors, reconciled by
claude agents --json, with attach/stop/rm/respawn/dispatch through the CLI.
…ose the agents roster

open-terminal runs `claude attach <id>` for a validated job id (no resume, sandbox, pre-launch or MCP), stop-session detaches an attach tab instead of killing it, and the roster module is wired to IPC, the preload API and the window teardown.

Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
… does not silence it

bgAgents.stop() clears its listeners when the window closes; a re-created window's next get-bg-agents now restores the bg-agents-changed push, idempotently.

Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
…anscript, stop, respawn and delete

Lists the roster the main process keeps, live jobs (working or blocked) first, with the verbs each state allows; opening the view is what arms the roster push. Ctrl/Cmd+Shift+A toggles it.

Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
…er viewer opens

A hide of an already-hidden view no longer clears the persisted flag, and the flag is read before the working-set restore runs. Memory, Work Files and Settings now close the view instead of stacking over it.

Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
The New agent button opens a dialog taking the prompt, project, name, agent and the New-Session permission options, and hands them to dispatchBgAgent. A refusal from main stays inline; a success closes the dialog and refreshes the roster, selecting the new row when the id is known.

Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
User doc, context doc with the known limits, and the rows in the README, shortcuts, IPC and cli-session-state docs.

Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
escapeHtml leaves double quotes alone, so a quote in a cwd, href or
session id could close the attribute and inject a data-verb that a click
would run.

Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
The CLI runs through an interactive login shell, so rc files that print
to stdout made every reconcile fail and the view blamed the daemon. The
list parse now falls back to the line-bounded JSON array inside the
noise, and a verb's error drops the shell's job-control warnings.

Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
A finished job kept its bg badge and its "click to attach" tooltip,
while a click on it resumes the session normally.

Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
The reattach branch of open-terminal did not say the live session was an
attach, so after a renderer reload the tab was treated as an ordinary
session.

Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
rm ran inside the job's cwd, which may be the directory it deletes;
Windows refuses to remove a live process's cwd. Respawn keeps the job
cwd, where its brief needs it.

Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
…tch dialog

Enter on Cancel both closed the dialog and started the agent.

Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
The CLI's --bg output and its untrusted-workspace refusal were measured
on 2.1.285; the stale unmeasured note goes, and the context doc now
covers the tolerant list parse, rm from home, the live-only badge, the
reattach flag and the attribute escaping.

Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
The daemon reports state failed for a job that ended in error (CLI 2.1.285, two live jobs); JOB_STATES dropped it to null and the row read '?'. It is finished like done and stopped: not live, filtered by Finished, Respawn and Delete enabled.

Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
A Group select in the view header (None / State / Project) splits the list into sections with a header and a count. The Finished filter and the sort apply first; headers are not rows, so selection and clicks are unchanged. The choice persists in localStorage.agentsGroupBy.

Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
Unset or invalid agentsGroupBy now means State; a stored none or project is kept. One AGENT_STATE_META map gives each state its emoji and label, used by the State headers and at the start of every row's state column. Section headers are larger and semi-bold, the count secondary.

Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
…ree sub-groups

Project mode keyed every cwd apart, so each worktree of a repo formed its own group. The main process now resolves projectRoot and worktreeRoot per cwd (the .claude/worktrees pattern, else one git rev-parse through execFile, cached, never blocking the roster) and Project mode groups by projectRoot. A Worktrees option, on by default and remembered, sub-groups a project by worktree when it spans more than one.

Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
Every group header (State, Project, worktree sub-group) is now a toggle: click, Enter or Space hides its rows, keeping the label and count. Keys are scoped by mode and level and persist in localStorage.agentsCollapsedGroups (capped at 200); roster pushes, regroups and the Finished filter keep them, and the selection stays.

Claude-Session: https://claude.ai/code/session_01WriJRPX84KSTHVY9wuKWyo
@devsuitup
devsuitup self-requested a review October 2, 2026 10:23
@devsuitup

Copy link
Copy Markdown
Owner

Reviewing 792481d (adversarial review in progress).

@paulo-jay

Copy link
Copy Markdown
Author

Thanks for the approval. I had already merged main again before it landed: 792481d has #402, #404, #406, #408 and #410, with no textual conflict, and task check passes locally on Linux.

On the attach flag through #402's generation path: an attach tab keeps it. The renderer sets entry.attach from the open options before the call and again from result.attach on a reattach (the reattached reply carries attach next to generation), and stop-session decides from session.isAttach in main. #402's drop path does not touch entry.attach. detachPty now sits on #408's idempotent writePty/killPty. I have not exercised this in a running Electron, only by reading the code and the tests.

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review at 792481d: a merge of current main (through #404). The resolution is right where both sides met. detachPty goes through writePty, so it inherits the #408 kill-once and no-write-after-kill guards, and the open-terminal replies carry both attach and generation. Full local task check at 792481d: lint 0 errors; 3136 tests, 0 failures. Approving.

@devsuitup

Copy link
Copy Markdown
Owner

CI and live test at 792481d.

CI: one test is red on Linux; Windows, lint and changelog are green. test/cli-session-state.test.js:467 "liveElsewhere reports the descriptor kind and jobId…" fails on both ubuntu jobs with Cannot read properties of null (reading 'pid') at line 473. The test writes a descriptor for pid 4242 and calls boot(dir, new Map()) without an isProcessAlive override, so the real liveness check runs. Pid 4242 does not exist on the runner and liveElsewhere returns null. Passing { isProcessAlive: () => true } to boot, as the readAllDescriptors test above it does, should fix it. Once CI is green this is ready to merge; the maintainer has approved merging on that condition.

Live test against the real daemon passed. Run on Windows 11 with CLI 2.1.287 and the real ~/.claude/jobs, in an isolated Switchboard instance:

  • The roster matches claude agents --json --all exactly: 7 bg jobs plus 3 interactive sessions, no banner.
  • Grouping by State, Project and none works; folding persists.
  • Dispatch through New agent works.
  • Attaching shows the live job.
  • Detaching by closing the tab leaves the job working and leaves no claude attach process behind.
  • Attach plus a double Stop returns {detached:true} twice; the app stays alive and the job keeps running.
  • Quitting with an attach tab open exits with code 0, and the job keeps running.
  • Stop and Delete from the view work.
  • The claude.cmd → bin\claude.exe path was exercised under a PowerShell profile: claude.exe ran as a direct child of the main process, and the list call took 729 ms.
  • No renderer or main-log errors.

Non-blocking follow-ups seen live (fine as separate issues):

  1. The attach tab renders the TUI misaligned. Text breaks at odd columns and the status line is drawn 2–5 times. This looks like a size mismatch between claude attach and the pty; it was not diagnosed.
  2. The state.json state can be stale in the other direction too. For several minutes the row showed blocked while the CLI said working, because state.json stayed at blocked. Preferring whichever source is newer, rather than always the file, would avoid this.
  3. The dispatch error keeps the login-shell noise. For example: bash: cannot set terminal process group (-1)… no job control in this shell Workspace not trusted…. stripShellNoise is applied to verb errors but not to dispatch errors.
  4. Delete took 33–37 s per job under the Git Bash profile, with no progress shown.

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at 792481d. The head is unchanged since 2 October, and the Linux test is still red. It is also 37 commits behind main, with textual conflicts in main.js, public/style.css, CHANGELOG.md and .ai/contexts/ipc-bridge.md, and 16 other files changed on both sides. The points below were found on the PR tree and re-checked by reading the code.

Blocking

  1. Linux CI (cli-session-state.test.js:467, "liveElsewhere reports the descriptor kind and jobId ..."). The fixture writes a descriptor for the fake PID 4242, but liveElsewhere probes the real /proc for that PID's start time and ancestry. On a Linux runner a real process can own PID 4242, so the probe returns null. Windows has no such probe, which is why it passes there. I could not reproduce it without the CI logs, so this is the likely cause rather than a proven one. Fix: inject readProcStart, readParentPid, ownPid and the platform into the synthetic fixture, as the neighbouring tests do, and keep the production PID-reuse protection.
  2. Dispatch dialog keeps the first project's settings (public/dialogs.js, showDispatchAgentDialog). effective is read once for the opening project. The project <select> has no change handler, so switching project keeps that project's Dangerous Skip choice and additional directories. An agent can then be dispatched with --dangerously-skip-permissions in a project that never enabled it. Recompute them when the project changes.
  3. Dispatch drops the sandbox and the pre-launch command (bg-agents-roster.js, dispatchArgs). It forwards the permission mode, --dangerously-skip-permissions and --add-dir, but nothing for a configured sandbox or pre-launch command. A project that runs its sessions sandboxed gets an unsandboxed background agent, without any message. Either carry them over, or refuse and say why.
  4. Deleting a session can remove a live background worker's transcript (main.js, delete-session). The guard is activeSessions.has(id), which only knows the app's own terminals. A session that a daemon job is running is not in that map.

Non-blocking

  • Remote project paths lose their host when they are passed to the dispatch dialog and become local destinations.
  • A global WSL profile can point at a different daemon from the one the Windows roster reads.
  • Live jobs keep the Fork action, and archiving can treat a client detach as the worker ending.
  • Rationale comments in claude-binary.js and agents-view.js, repeated defaults, and the new styles do not use the shared control classes of #468.

Rebase hazards beyond the textual conflicts. The attach and resize paths must keep main's options: attach, generation, remoteResizeAllowed and the Refresh control (#453, #455). The folder archive handling of #482 needs a decision for background workers. The trigger delivery of #439 and the restore planner assume session ids that the Agents view can now replace.

Verified: 206 related tests pass on Windows, Node 24 (2 Linux-only tests skipped). Not verified: Ubuntu Node 20/22, c8, and the merged result.

Not mergeable as it stands. Once the points above are addressed and the branch is rebased on current main, I will review the new head.

pjay added 2 commits October 7, 2026 11:10
…-agents-view-impl

Conflicts: CHANGELOG.md (the Agents entry moves to the new Unreleased
section), main.js (keeps the bg-agents requires next to main's
terminal-resize handler; resizePty is no longer used here),
public/style.css (the frameless no-drag lists keep the Agents header
labels and take main's .modal-overlay), .ai/contexts/ipc-bridge.md
(both sections kept; bg-agents-changed added to the event list).

main's theme-controls test requires every index.html button to be styled
through a class: New agent gets .agents-header-btn instead of its id.

Claude-Session: https://claude.ai/code/session_01TLG3EcjMdqPmYky3m2QrYA
…e kind test

The test wrote a descriptor for pid 4242 and let the real /proc probe run.
On a CI runner where pid 4242 exists, its start time differs from the
descriptor's procStart, so the descriptor was dropped and liveElsewhere
returned null. boot() now forwards readProcStart and readParentPid, and the
test passes a probe that matches the descriptor.

Claude-Session: https://claude.ai/code/session_01TLG3EcjMdqPmYky3m2QrYA
@paulo-jay

Copy link
Copy Markdown
Author

Thanks for the live test. Pushed 2150fd6; task check passes locally on Linux (4532 pass, 0 fail).

Red Linux test. boot() already defaults isProcessAlive to () => true, so liveness was not the cause. On Linux liveElsewhere also compares the descriptor's procStart with the real /proc/<pid>/stat: where pid 4242 exists (the runners), its start time differs from the descriptor's 111 and the descriptor is dropped; where it does not exist (my machine), the probe returns null and the check is skipped. A probe returning a different value reproduces the exact CI error (Cannot read properties of null (reading 'pid')). boot() now forwards readProcStart / readParentPid, and the test passes a probe matching the descriptor.

Merge of main (747c830, up to #482). Conflicts in CHANGELOG.md (the Agents entry moves to the new ## Unreleased), main.js (requires; resizePty dropped since main's terminal-resize handler owns it), public/style.css (frameless no-drag lists keep the Agents header labels and take .modal-overlay) and .ai/contexts/ipc-bridge.md (both sections kept). One semantic fix: main's theme-controls test requires every index.html button to be styled through a class, so New agent gets .agents-header-btn (count 23 → 24).

The four non-blocking follow-ups (attach TUI size, newer-source state, dispatch error noise, slow delete) are left for separate issues as you suggested.

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at 2150fd6. Thanks for the rebase on main and for the test fix: injecting readProcStart and readParentPid into boot() addresses the fake-PID collision I suspected. CI on this head was only just approved, so the Linux result is not in yet.

Three of the earlier blocking points are still there, checked by reading the PR tree:

  1. Dispatch dialog keeps the first project's settings (public/dialogs.js, showDispatchAgentDialog). effective is still read once, at line 177, and the project <select> has no change handler, so switching project keeps the first project's Dangerous Skip choice and additional directories.
  2. Dispatch still drops the sandbox and the pre-launch command (bg-agents-roster.js, dispatchArgs). Nothing about either appears in the file, so a project that runs sandboxed gets an unsandboxed background agent without any message.
  3. delete-session is still guarded by activeSessions.has(id) only (main.js). A session run by a daemon job is not in that map, so its transcript can be deleted under a live worker.

The non-blocking points from the previous review are unchanged. I will review again once these are addressed and the Linux tests are green on the exact head.

pjay added 2 commits October 7, 2026 11:36
…ed dispatch and live deletes

The dispatch dialog read the effective settings once, for the opening
project: switching project kept its Dangerous Skip choice and additional
directories. It now re-reads them on every project change, and no longer
offers remote projects, whose path would become a local destination.

The daemon starts a background worker itself, so a project's sandbox and
pre-launch command cannot apply to it. Dispatch in such a project is now
refused, main-side and in the dialog, instead of running the agent
unconfined without a word.

delete-session only knew the app's own terminals, so a transcript a daemon
job is still writing could be deleted. It now also refuses a session that a
live roster job or another process holds.

The liveElsewhere kind test also pins ownPid and the platform.

Claude-Session: https://claude.ai/code/session_01TLG3EcjMdqPmYky3m2QrYA
@paulo-jay

Copy link
Copy Markdown
Author

Thanks. I had missed your review of this morning before pushing 2150fd6, sorry. Pushed c280cac and 3c279d6; task check passes locally on Linux (4540 pass, 0 fail). Nothing here was run on Windows.

Blocking

  1. Linux CI: the fixture now also pins ownPid and platform: 'linux', on top of readProcStart / readParentPid, so the test no longer touches /proc on any OS. The production PID-reuse check is unchanged.
  2. Dispatch dialog: the effective settings are re-read on every project change (a token drops a stale reply), and mode, Dangerous Skip and additional directories are reset from them. A new test switches from a Dangerous Skip project to a plan-mode one and checks the payload.
  3. Sandbox / pre-launch command: dispatch is refused. The daemon starts the worker, so wrapping the client in bubblewrap or running the pre-launch command would not reach it. Main resolves the settings for the dispatch cwd (resolveScheduleSandbox, then preLaunchCmd project → global → default) and dispatchRefusal returns the reason before any claude call. The dialog shows the same reason and keeps Start disabled.
  4. delete-session: it also refuses when bgAgents.liveJobForSession(id) (a working/blocked roster entry) or cliSessionState.liveElsewhere(id, …) reports the session, before anything is resolved or removed. The second check also covers a worker when the Agents view was never opened, since the roster is empty until then.

Non-blocking

Rebase hazards: the reattached reply carries attach, generation and remoteResizeAllowed. Attach tabs use main's terminal-resize handler and Refresh control unchanged. I have not checked the #439 trigger delivery or the restore planner against the Agents view replacing a session id.

@paulo-jay

Copy link
Copy Markdown
Author

Follow-up d61369a: New agent and the row verbs now use .control-btn, and the Group select uses .control-select (#468), with only a compact size override left in the Agents rules. claude-binary.js's rationale comment moved to "Running the CLI" in .ai/contexts/bg-agents.md. I found no rationale comment in agents-view.js beyond the one-line pointer, and I am not sure which repeated defaults you meant: could you point at one? task check passes on Linux.

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at d61369a. Thanks: the three earlier blockers are addressed in shape. The dialog follows the chosen project with a stale-response guard, dispatch refuses an exact-project sandbox or pre-launch command, and delete-session now asks the roster and the descriptors. The branch is level with main. Three points still block; the reviewer reproduced each with the shipped code, and I re-read the paths.

Blocking

  1. delete-session does not fail closed when liveness is unknown (main.js, the liveJobForSession || liveElsewhere check; bg-agents.js liveJobForSession). With the roster absent or stale, liveJobForSession returns null, and liveElsewhere returns nothing when the descriptor directory cannot be read. The transcript of a session a daemon is still running is then deleted. Fix: refuse when the roster has not been loaded or the descriptor directory cannot be read, and say why. Test: no roster loaded plus an unreadable descriptor directory must refuse.
  2. An uppercase session id bypasses both liveness checks (bg-agents.js liveJobForSession, e.sessionId === sessionId; cli-session-state.js sessionIds.has(raw.sessionId)). On Windows the same transcript resolves case-insensitively, so a differently cased id passes both matches and the delete goes through. Fix: normalise the id (lowercase) on both sides before comparing. Test: delete with an uppercase id while a live job holds the lowercase one.
  3. A failed dispatch re-enables Start while the next project's settings lookup is pending (public/dialogs.js, start()). startBtn.disabled = false runs unconditionally after the call returns. If the user has switched project in the meantime, Retry sends the new cwd with the previous project's Dangerous Skip and additional directories. Fix: re-enable only when no lookup is pending, or send the fields of the project that was last resolved. Test: fail a dispatch, switch project with an unresolved lookup, and check that Start stays disabled.

Non-blocking

  • test/delete-session.test.js and test/dom-dispatch-dialog.test.js assert on source strings and use lookups that resolve at once, so they would not fail if these guards were removed.

217 tests in the 18 related files pass locally on Node 24. Not run: Linux Node 20/22, c8, lint, fresh CI. CI on this head still needs its approval; I will approve it once the points above are fixed.

… hold Start during a project lookup

delete-session now asks delete-session-guard.js. It refuses when it
cannot tell whether a session is live: no roster and an unreadable jobs
directory, an unreadable descriptor directory or state, or a live job
whose state names no session. liveJobCheck reads the job files directly,
so the check works before the Agents view is ever opened.

Session ids are compared lowercased in the roster, the job files, the
descriptors and the open terminals: Windows resolves a transcript path
case-insensitively, so an uppercase id passed every check.

A failed dispatch re-enabled Start while the next project's settings were
loading; a retry sent the new cwd with the previous project's options.
Start now stays disabled while a lookup is pending, and dispatch sends the
project whose settings were last applied.

Claude-Session: https://claude.ai/code/session_01TLG3EcjMdqPmYky3m2QrYA
@paulo-jay

Copy link
Copy Markdown
Author

Thanks. Pushed 966878e; task check passes locally on Linux (4548 pass, 0 fail). Nothing here was run on Windows.

  1. Fail closed. The check moved to delete-session-guard.js (deleteSessionRefusal), which the handler calls before resolving anything. It refuses, and says why, when it cannot tell whether the session is live:

    • an unreadable jobs directory, descriptor directory, state.json or descriptor;
    • a live job whose state names no session.

    A missing directory still counts as "nothing live". bgAgents.liveJobCheck checks the roster first, then reads every jobs/*/state.json directly, so a roster that was never loaded is not a blind spot. cliSessionState.liveElsewhereChecked returns {known:false, reason} instead of an empty answer.

  2. Case. Session ids are compared lowercased in the roster, the job files, the descriptors and the open terminals (by id and realSessionId). liveElsewhereMany still answers under the ids it was asked.

  3. Start. It stays disabled while a project lookup is pending, after a failed dispatch included. start() refuses during a lookup, and dispatch sends the project whose settings were last applied rather than the select's current value.

Tests. test/delete-session-guard.test.js runs the real bg-agents and cli-session-state against temp dirs. It covers:

  • no roster plus an unreadable jobs directory;
  • an unreadable descriptor directory;
  • a live job and a live descriptor under an uppercase id, and the reverse;
  • a live job naming no session;
  • an open terminal by realSessionId.

The new dialog test holds the second project's lookup unresolved while a dispatch fails. I checked that the tests catch regressions: each of the four guards (lowercasing in both modules, fail-closed in both) and the old unconditional startBtn.disabled = false, when reverted, turns its test red. The source-regex test in delete-session.test.js now only checks that the handler calls the guard before resolveDeletionTargets.

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at 966878e. The delete guard now exists as a shared check, ids are matched in any case in the two liveness lookups, and the failed-dispatch case with a pending lookup now holds Start. Three points still block; each was reproduced by the reviewer with the shipped code and I re-read the paths.

Blocking

  1. An unrecognised job state counts as "not live" (bg-agents.js liveJobCheck, bg-agents-roster.js isLiveJobState). isLiveJobState is true only for working and blocked, and liveJobCheck treats anything else as a finished job, so a state the daemon adds later (or a typo in state.json) returns { known: true, job: null } and the delete goes through. Liveness is unknown in that case, not empty: return known: false when the state is not one of the listed JOB_STATES. Test: a state.json with a state outside JOB_STATES must refuse.
  2. A stale cached working roster entry overrides a fresh done file (bg-agents.js, the fromRoster branch). The roster match returns before the job files are read. If the observation fails (the Agents view hidden, the watcher stopped), the cache stays working forever and the session can never be deleted. Prefer the job file when it can be read, and use the cache only as a fallback. Test: cached working plus a done file on disk must allow the delete.
  3. A rejected settings lookup becomes {} and re-enables Start (public/dialogs.js, effectiveFor catch). The dialog then dispatches with empty settings (no Dangerous Skip choice, no additional directories) as if the project had none. Keep Start disabled and show the error when the lookup fails. Test: reject getEffectiveSettings and check that Start stays disabled.

Non-blocking

  • Case-sensitive id lookups remain in the status caches and the badge sets (cli-session-state.js ~335, public/agents-view.js ~189), so a differently cased id still misses its badge.
  • test/delete-session.test.js still asserts on source strings: replacing the endpoint's liveness callbacks with known-empty stubs leaves it green.

206 related tests pass locally on Node 24, plus 66 in complementary files (2 Linux-only skipped). Not run: Linux Node 20/22, c8, lint, fresh CI. CI on this head is not approved yet; I will approve it once the points above are fixed.

…known states and failed settings lookups

liveJobCheck read the cached roster first, so a stale "working" entry
blocked a delete forever after the job file said "done". The job files now
decide; the roster only supplies a session id a file lacks, or answers when
jobs/ does not exist. A state outside JOB_STATES is unknown, not finished,
and refuses the delete.

The dispatch dialog turned a rejected settings lookup into empty settings
and re-enabled Start. It now keeps Start disabled and shows the error.

main builds the delete guard with makeDeleteSessionGuard from the real
modules, which the guard tests use too. The bg badge matches session ids in
any case.

Claude-Session: https://claude.ai/code/session_01TLG3EcjMdqPmYky3m2QrYA
@paulo-jay

Copy link
Copy Markdown
Author

Thanks. Pushed 37ebdb4 (still level with main); task check passes locally on Linux (4553 pass, 0 fail). Nothing here was run on Windows.

  1. Unknown state. A state.json whose state is outside JOB_STATES (or missing) now gives known: false, and the delete is refused with the reason. Test: a job in state paused refuses.
  2. Stale cache. liveJobCheck now reads the job files first and lets them decide. The roster is only used to supply a session id that a file lacks, or to answer when jobs/ does not exist. Test: reconcile while the file says working, rewrite it to done, assert the cached roster entry is still working, then the delete is allowed.
  3. Failed settings lookup. A rejected or empty getEffectiveSettings keeps Start disabled and shows "Could not read this project's settings (…)", both for the opening project and after a project change. Tests cover both.

Each of the three fixes, when reverted, turns its test red: 1, 1 and 2 failures.

Non-blocking

  • The delete endpoint: main.js now builds the guard with makeDeleteSessionGuard({ activeSessions, bgAgents, cliSessionState, sessionHasPty, ptyPids }), and delete-session-guard.test.js goes through that same factory with the real modules. The remaining source assertion checks that the handler calls it first and that main passes the real modules, not stubs.
  • Badge: bgAgentSessionIds holds lowercased ids and the sidebar looks rows up lowercased, with a test.
  • I left cli-session-state.js's statusBySession / getStatus alone: it predates this PR (upstream code) and drives the busy state of every session. Lowercasing it belongs in its own change.

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at 37ebdb4. The three blockers of 966878e are fixed: an unknown or missing state refuses the delete, a readable job file wins over a stale cached working entry, and a rejected settings lookup keeps Start disabled, on the first lookup and after a project change, with a later successful lookup recovering it. One point still blocks, found by the reviewer and confirmed by reading.

Blocking

  1. A cached live job is ignored when its state.json is missing (bg-agents.js, liveJobCheck). The cache is consulted only when jobs/ itself does not exist. When jobs/ exists but a job's directory or state.json is absent (ENOENT on the read), the loop does continue and never looks at the roster, so a job that the roster lists as working is treated as not running and the delete goes through. Fix: when the file is missing, fall back to the cached roster entry for that job id (live and same session means refuse). Test: cached working entry with jobs/ present and no state.json for it must refuse.

Non-blocking

  • cli-session-state.js (~335): the status-cache keys are still case-sensitive, so an uppercase id misses the busy status of a lowercase descriptor.
  • public/sidebar.js (~1170): archiving a project folder archives background sessions after detaching their clients, while their workers keep running. This needs a decision against main's folder archive from #482 (refuse, or stop the worker first).
  • test/delete-session.test.js still asserts on source strings for the endpoint wiring; replacing the liveness callbacks with known-empty stubs would leave it green.

214 required tests and 130 complementary ones pass locally on Node 24 (2 Linux-only skipped). Not run: Linux Node 20/22, c8, lint, fresh CI. CI on this head is not approved yet; I will approve it once the blocking point is fixed.

… archiving a live job's session

liveJobCheck fell back to the roster only when jobs/ did not exist. A
listed live job whose directory or state.json is missing now refuses the
delete too.

Archiving a session a live daemon job runs, alone or with its folder
(devsuitup#482), detached the client and left the worker running under an archived
session. stopBeforeArchive now asks main (bg-agent-live-job) first and
refuses, before detaching anything, on a live job or an unknown answer.

getStatus matches descriptor ids in any case.

Claude-Session: https://claude.ai/code/session_01TLG3EcjMdqPmYky3m2QrYA
@paulo-jay

Copy link
Copy Markdown
Author

Thanks. Pushed 8b3e7ef (level with main); task check passes locally on Linux (4558 pass, 0 fail). Nothing here was run on Windows.

Blocking

  1. Cached live job without a state file. After the job files are read, liveJobCheck now also checks the roster for a live job with the same session whose state.json could not be read because it is missing (no job directory, or an empty one). It refuses. A readable file still wins over the cache. Test: a working entry cached through reconcile(), with jobs/ present, in both layouts and with an uppercase id. Removing the fallback turns it red.

Non-blocking

  • Archive ((sidebar): archiving a project folder hides it until a session appears in it #482). My decision is to refuse. stopBeforeArchive now asks main first through bg-agent-live-job (the same liveJobCheck). On a live job, an unknown answer, or a failed IPC call, it returns {ok:false} before detaching anything. That covers the session archive, slug-group archive, folder archive and delete call sites:

    • a folder that holds a live job's session archives nothing, and the reason shows on that row's button;
    • the user stops the job from the Agents view first.

    Tests in dom-sidebar-stop-before-archive.test.js cover a live job, an unknown answer, a rejected IPC call and the folder archive. The existing call-order assertions now include the check.

  • getStatus case. statusBySession and lastProbeAt are keyed lowercased, with a test asking for SESS-1.

  • delete-session.test.js. It is still a source assertion, but now a narrow one: the handler calls deleteSessionGuard(id) first, and main.js builds that guard with makeDeleteSessionGuard({ activeSessions, bgAgents, cliSessionState, sessionHasPty, ptyPids }). The liveness callbacks no longer live in main.js: they are inside the factory, which delete-session-guard.test.js runs with the real modules. Replacing them with stubs would mean editing the factory, and that turns the guard tests red.

@devsuitup devsuitup left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review at 8b3e7ef. The blocker of 37ebdb4 is fixed: a cached live job whose state.json or directory is missing now refuses the delete, and archiving a folder refuses (or stops first) a live background job's session. The branch is level with main. 346 tests in the 36 related files pass locally on Node 24, plus 179 in 14 complementary files (2 Linux-only skipped).

The reviewer asked for changes because findSession and the descriptor comparison in cli-session-state.js (~205, ~225, ~246) still compare session ids strictly, so a mixed-case descriptor updates the status cache but misses the busy-to-idle rescan. I read it and it is real, but I do not treat it as blocking: it concerns the rescan of a session whose descriptor id differs in case from the app's id, the CLI writes lowercase UUIDs, and the delete guard, which is the safety property, no longer depends on it. Please fix it in this PR or a follow-up.

Non-blocking

  • cli-session-state.js: normalise the id in findSession, the descriptor comparison and the rescan keys the same way as in statusKey.
  • Missing tests: the negative cached-session case (a live entry of another session must not refuse), uppercase descriptor insertion and removal, and the new IPC route in test/bg-agents-ipc.test.js.
  • docs/background-agents.md (~134) says a folder archive refuses a live job unconditionally, but public/sidebar.js (~1167) skips the check when "Archive the sessions" is unchecked. Align the text or the code.
  • main.js (~2449, ~2462, ~2491) still has rationale comments; they belong in docs/background-agents.md, at most a one-line pointer in the code.

Not run: Linux Node 20/22 with c8, lint, fresh CI. The CI runs on this head still need to be approved; I am doing that now, and the merge waits for them to be green.

@devsuitup
devsuitup merged commit d165362 into devsuitup:main Oct 7, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants